Skip to content

Conversation

stuartmorgan-g
Copy link
Collaborator

Rather than interpreting ExoPlayer event callbacks in Java and then sending event channel events that match the Dart platform interface, send the events directly and intepret them in Dart. This continues the process of reducing Java-side logic in the plugin.

In order to make this change less error-prone, it also adopts Pigeon event channels, so that the new events are type-safe; in order to do that, this switches to the Kotlin Pigeon generator, since Pigeon event channels haven't been implemented for Java.

Now that the buffer progress event synthesis is in Dart, this also fixes an existing TODO by reworking that logic to be based on an independent timer, rather than driven by playback position polling from the app-facing package. This both makes the logic clearer, and makes it work regardless of whether or not the video is paused.

Part of flutter/flutter#172763

Pre-Review Checklist

Footnotes

  1. Regular contributors who have demonstrated familiarity with the repository guidelines only need to comment if the PR is not auto-exempted by repo tooling. 2 3

Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request is a significant and well-executed refactoring that moves Android video event logic to Dart, improving type safety by adopting Pigeon event channels and switching to Kotlin for code generation. The changes simplify the native Android code by sending more raw events to be interpreted by the Dart side. Additionally, it improves the buffer progress update logic to be more reliable.

I've found two critical fall-through bugs in the new Dart event handling logic that would cause compilation errors, and a medium-severity issue related to the robustness of the new buffer polling timer. My review comments provide suggestions to fix these issues.

Comment on lines 366 to 370
_bufferPollingTimer = Timer.periodic(const Duration(seconds: 1), (
Timer timer,
) async {
_updateBufferPosition(await _api.getBufferedPosition());
});

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using Timer.periodic with an async callback can lead to a few issues. If getBufferedPosition() takes longer than one second, another call will be initiated before the first one completes, causing overlapping executions. Additionally, if dispose() is called while getBufferedPosition() is in flight, _updateBufferPosition could be called after the player instance has been disposed.

A more robust pattern is to use a recursive Timer that schedules the next poll only after the previous one completes. For a minimal fix that addresses the use-after-dispose issue, I've added checks for _isDisposed before and after the await.

Suggested change
_bufferPollingTimer = Timer.periodic(const Duration(seconds: 1), (
Timer timer,
) async {
_updateBufferPosition(await _api.getBufferedPosition());
});
_bufferPollingTimer = Timer.periodic(const Duration(seconds: 1), (
Timer timer,
) async {
if (_isDisposed) {
timer.cancel();
return;
}
final int position = await _api.getBufferedPosition();
if (_isDisposed) {
return;
}
_updateBufferPosition(position);
});

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If getBufferedPosition() takes longer than one second, another call will be initiated before the first one completes

I'm fine with potential bunching up of events if the main thread gets stuck for a second.

Additionally, if dispose() is called while getBufferedPosition() is in flight, _updateBufferPosition could be called after the player instance has been disposed.

Fixed.

if (surfaceProducerHandlesCropAndRotation) {
// When the SurfaceTexture backend for Impeller is used, the preview should already
// be correctly rotated.
rotationCorrection = RotationDegrees.ROTATE_0;
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was a drive-by cleanup of a warning that the IDE linter (but not the command-line linter we have in CI) gave about setting this to the value it already had.

events.onInitialized(width, height, exoPlayer.getDuration(), rotationCorrection.getDegrees());
}

private RotationDegrees getRotationCorrectionFromUnappliedRotation(
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was dead code missed when we dropped support for older API versions.

final class QueuingEventSink implements EventChannel.EventSink {
private EventChannel.EventSink delegate;
final class QueuingEventSink {
private PigeonEventSink<PlatformVideoEvent> delegate;
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't bother to template the class since this is the only case we use. In the unlikely event that we need other types later, we can always adjust then.


@Override
public void setLooping(@NonNull Boolean looping) {
public void setLooping(boolean looping) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These minor type changes are because the Kotlin generator and Java generator don't produce identical interfaces.

}

@Override
public @NonNull Messages.PlaybackState getPlaybackState() {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This multi-return getter was just to support the combined polling hack that is replaced by the timer.

}

@Test
public void onPlaybackStateChangedIdleAfterBufferingSendsBufferingEnd() {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are fewer tests here now because the logic this was testing moved to Dart.

}

@Test
public void onInitializedIncludesRotationCorrectIfNonZero() {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the typed version, rotation is always sent, so there's no need to test with and without rotation differently.

const int playerId = 1;
const String mockChannel = 'flutter.io/videoPlayer/videoEvents$playerId';
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMessageHandler(mockChannel, (ByteData? message) async {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than one giant test, which was hard to understand and maintain, I broke it apart into individual tests for each event/behavior being tested.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant